Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 58f1f5e2b4d7796d19d2bc5dfe0a253e91a0ad2a


Parents : 84617fe
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-07T05:59:27-05:00

feat(NomadNetwork): update tab navigation and state management with error handling and synchronization checks

Changes
Diff

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
index 26ee5cb6..0bcc29b9 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
@@ -49,14 +49,16 @@
</button>
</div>
- <div class="flex flex-1 min-h-0 min-w-0 overflow-hidden">
+ <div class="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
<NomadNetworkPage
v-for="tab in tabs"
v-show="tab.id === activeTabId"
:key="tab.id"
:ref="(el) => setPageRef(tab.id, el)"
+ class="absolute inset-0 flex min-h-0 min-w-0"
embedded
:tabs-enabled="tabsEnabled"
+ :is-active="tab.id === activeTabId"
:destination-hash="tab.destinationHash"
:initial-path="tab.initialPath"
@navigate="onTabNavigate(tab.id, $event)"
@@ -436,8 +438,31 @@ export default {
if (this.activeTabId === tabId) {
return;
}
+ const tab = this.tabs.find((entry) => entry.id === tabId);
+ if (!tab) {
+ ToastUtils.warning(this.$t("nomadnet.tab_switch_failed"));
+ return;
+ }
this.activeTabId = tabId;
this.syncRoute();
+ this.$nextTick(() => {
+ this.verifyActiveTabPage(tab);
+ });
+ },
+ verifyActiveTabPage(tab) {
+ const page = this.pageRefs[tab.id];
+ if (!page || typeof page.getEmbeddedTabStateHash !== "function") {
+ return;
+ }
+ const expectedHash = (tab.destinationHash || "").trim();
+ const loadedHash = page.getEmbeddedTabStateHash();
+ if (!expectedHash || !loadedHash || expectedHash === loadedHash) {
+ return;
+ }
+ if (typeof page.restoreEmbeddedTabState === "function") {
+ page.restoreEmbeddedTabState(expectedHash, tab.path);
+ }
+ ToastUtils.warning(this.$t("nomadnet.tab_content_mismatch"));
},
onTabDragStart(index, event) {
this.dragTabIndex = index;
@@ -532,21 +557,31 @@ export default {
if (!destinationHash) {
return;
}
- if (this.$route?.name !== "nomadnetwork") {
- this.$router
- .push({
- name: "nomadnetwork",
- params: { destinationHash },
- query: { newTab: "1" },
- })
- .catch(() => {});
- return;
+ try {
+ if (this.$route?.name !== "nomadnetwork") {
+ this.$router
+ .push({
+ name: "nomadnetwork",
+ params: { destinationHash },
+ query: { newTab: "1" },
+ })
+ .then(() => {
+ this.consumeNewTabRouteQuery(this.$route);
+ })
+ .catch(() => {
+ ToastUtils.error(this.$t("nomadnet.open_node_failed"));
+ });
+ return;
+ }
+ this.onOpenNode({
+ ...payload,
+ destinationHash,
+ forceNewTab: payload?.forceNewTab !== false,
+ });
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("nomadnet.open_node_failed"));
}
- this.onOpenNode({
- ...payload,
- destinationHash,
- forceNewTab: payload?.forceNewTab !== false,
- });
},
consumeNewTabRouteQuery(route) {
if (route?.name !== "nomadnetwork" || route.query?.newTab !== "1") {

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 1920df70..e15befd8 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -651,6 +651,10 @@ export default {
type: Boolean,
default: false,
},
+ isActive: {
+ type: Boolean,
+ default: true,
+ },
initialPath: {
type: String,
required: false,
@@ -1058,36 +1062,31 @@ export default {
}
// load nomadnetwork node if a destination hash was provided on page load
- if (this.destinationHash) {
+ const bootstrapHash = (this.destinationHash || "").trim();
+ if (bootstrapHash) {
+ const bootstrapPath = this.embedded ? this.initialPath : this.$route.query.path;
+ const bootstrapArchiveId = this.embedded ? null : this.$route.query.archive_id;
(async () => {
- // fetch updated announce as we are probably loading node page before we loaded the announces list
- await this.getNomadnetworkNodeAnnounce(this.destinationHash);
+ await this.getNomadnetworkNodeAnnounce(bootstrapHash);
- // set selected node so the viewer shows up
- if (this.nodes[this.destinationHash]) {
- this.selectedNode = this.nodes[this.destinationHash];
+ if (this.nodes[bootstrapHash]) {
+ this.selectedNode = this.nodes[bootstrapHash];
} else {
- // if no announce found, create a placeholder node so we can still view archives
this.selectedNode = {
- destination_hash: this.destinationHash,
+ destination_hash: bootstrapHash,
display_name: "Unknown Node",
aspect: "nomadnetwork.node",
};
}
- // get path to destination
- this.getNodePath(this.destinationHash);
-
- // check if we have a path or archive_id in query params
- const path = this.embedded ? this.initialPath : this.$route.query.path;
- const archiveId = this.embedded ? null : this.$route.query.archive_id;
+ this.getNodePath(bootstrapHash);
- if (archiveId) {
- await this.loadArchivedPage(archiveId);
- } else if (path) {
- await this.onNodePageUrlClick(`${this.destinationHash}:${path}`);
+ if (bootstrapArchiveId) {
+ await this.loadArchivedPage(bootstrapArchiveId);
+ } else if (bootstrapPath) {
+ await this.onNodePageUrlClick(`${bootstrapHash}:${bootstrapPath}`);
} else {
- await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
+ await this.onNodePageUrlClick(`${bootstrapHash}:${this.defaultNodePagePath}`);
}
})();
}
@@ -1103,6 +1102,61 @@ export default {
this.$nextTick(() => this.scheduleProcessPartials());
},
methods: {
+ getEmbeddedTabStateHash() {
+ return (this.selectedNode?.destination_hash || "").trim();
+ },
+ async restoreEmbeddedTabState(destinationHash, pagePath = null) {
+ const hash = (destinationHash || "").trim();
+ if (!this.embedded || !hash) {
+ return;
+ }
+ try {
+ await this.getNomadnetworkNodeAnnounce(hash);
+ this.selectedNode = this.nodes[hash] || {
+ destination_hash: hash,
+ display_name: this.$t("nomadnet.unknown_node"),
+ aspect: "nomadnetwork.node",
+ };
+ const path = typeof pagePath === "string" && pagePath.length > 0 ? pagePath : this.defaultNodePagePath;
+ await this.loadNodePage(hash, path, null, false, true);
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("nomadnet.tab_restore_failed"));
+ }
+ },
+ ownsNomadPageDownloadEvent(nomadnetPageDownload, downloadId) {
+ const responsePagePath = `${nomadnetPageDownload.destination_hash}:${nomadnetPageDownload.page_path}`;
+ const callbackKey = this.getNomadnetPageDownloadCallbackKey(
+ nomadnetPageDownload.destination_hash,
+ nomadnetPageDownload.page_path
+ );
+ if (this.nomadnetPageDownloadCallbacks[callbackKey]) {
+ return true;
+ }
+ if (this.currentPageDownloadId !== null && this.currentPageDownloadId === downloadId) {
+ return true;
+ }
+ if (!this.nodePagePath || this.nodePagePath !== responsePagePath) {
+ return false;
+ }
+ if (this.isLoadingNodePage) {
+ return true;
+ }
+ return !this.embedded || this.isActive;
+ },
+ ownsNomadFileDownloadEvent(nomadnetFileDownload, downloadId) {
+ const callbackKey = this.getNomadnetFileDownloadCallbackKey(
+ nomadnetFileDownload.destination_hash,
+ nomadnetFileDownload.file_path
+ );
+ if (this.nomadnetFileDownloadCallbacks[callbackKey]) {
+ return true;
+ }
+ if (this.currentFileDownloadId !== null && this.currentFileDownloadId === downloadId) {
+ return true;
+ }
+ return !this.embedded || this.isActive;
+ },
/**
* Returns true if the given page content represents a failed load.
* Matches the explicit "request_failed" sentinel and the user-facing
@@ -1271,14 +1325,15 @@ export default {
break;
}
case "nomadnet.page.download": {
- // get data from server
const nomadnetPageDownload = json.nomadnet_page_download;
const downloadId = json.download_id;
- // get response page path
+ if (!this.ownsNomadPageDownloadEvent(nomadnetPageDownload, downloadId)) {
+ break;
+ }
+
const responsePagePath = `${nomadnetPageDownload.destination_hash}:${nomadnetPageDownload.page_path}`;
- // handle success for archived versions first (before path check)
if (nomadnetPageDownload.status === "success" && nomadnetPageDownload.is_archived_version) {
this.nodePagePath = responsePagePath;
this.nodePagePathUrlInput = responsePagePath;
@@ -1311,8 +1366,14 @@ export default {
}
}
- // handle started status
if (nomadnetPageDownload.status === "started") {
+ const startedCallbackKey = this.getNomadnetPageDownloadCallbackKey(
+ nomadnetPageDownload.destination_hash,
+ nomadnetPageDownload.page_path
+ );
+ if (!this.nomadnetPageDownloadCallbacks[startedCallbackKey]) {
+ break;
+ }
if (this.pendingNomadPageCancelWithoutId) {
this.pendingNomadPageCancelWithoutId = false;
WebSocketConnection.send(
@@ -1380,12 +1441,21 @@ export default {
break;
}
case "nomadnet.file.download": {
- // get data from server
const nomadnetFileDownload = json.nomadnet_file_download;
const downloadId = json.download_id;
- // handle started status
+ if (!this.ownsNomadFileDownloadEvent(nomadnetFileDownload, downloadId)) {
+ break;
+ }
+
if (nomadnetFileDownload.status === "started") {
+ const fileCallbackKey = this.getNomadnetFileDownloadCallbackKey(
+ nomadnetFileDownload.destination_hash,
+ nomadnetFileDownload.file_path
+ );
+ if (!this.nomadnetFileDownloadCallbacks[fileCallbackKey]) {
+ break;
+ }
this.currentFileDownloadId = downloadId;
return;
}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index e2606cae..e42b0809 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1639,7 +1639,11 @@
"context_menu_action_failed": "Kontextmenü-Aktion fehlgeschlagen",
"context_menu_favourite_failed": "Favorit konnte nicht aktualisiert werden",
"favourite_added": "Zu Favoriten hinzugefügt",
- "favourite_removed": "Aus Favoriten entfernt"
+ "favourite_removed": "Aus Favoriten entfernt",
+ "tab_switch_failed": "Wechsel zu diesem Tab nicht möglich",
+ "tab_content_mismatch": "Dieser Tab war nicht synchron und wird neu geladen",
+ "tab_restore_failed": "Seite dieses Tabs konnte nicht wiederhergestellt werden",
+ "open_node_failed": "NomadNet-Knoten konnte nicht geöffnet werden"
},
"forwarder": {
"title": "LXMF-Weiterleiter",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 9dbdd95c..9f512820 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1751,7 +1751,11 @@
"context_menu_action_failed": "Context menu action failed",
"context_menu_favourite_failed": "Could not update favourite",
"favourite_added": "Added to favourites",
- "favourite_removed": "Removed from favourites"
+ "favourite_removed": "Removed from favourites",
+ "tab_switch_failed": "Could not switch to that tab",
+ "tab_content_mismatch": "This tab was out of sync and is being reloaded",
+ "tab_restore_failed": "Could not restore this tab's page",
+ "open_node_failed": "Could not open the NomadNet node"
},
"forwarder": {
"title": "LXMF Forwarder",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index a5a4ec5c..ef2cc0ec 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1751,7 +1751,11 @@
"context_menu_action_failed": "Error en la acción del menú contextual",
"context_menu_favourite_failed": "No se pudo actualizar el favorito",
"favourite_added": "Añadido a favoritos",
- "favourite_removed": "Eliminado de favoritos"
+ "favourite_removed": "Eliminado de favoritos",
+ "tab_switch_failed": "No se pudo cambiar a esa pestaña",
+ "tab_content_mismatch": "Esta pestaña estaba desincronizada y se está recargando",
+ "tab_restore_failed": "No se pudo restaurar la página de esta pestaña",
+ "open_node_failed": "No se pudo abrir el nodo NomadNet"
},
"forwarder": {
"title": "LXMF Forwarder",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 207b50b9..db093e25 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1751,7 +1751,11 @@
"context_menu_action_failed": "Kontekstivalikon toiminto epäonnistui",
"context_menu_favourite_failed": "Suosikkia ei voitu päivittää",
"favourite_added": "Lisätty suosikkeihin",
- "favourite_removed": "Poistettu suosikeista"
+ "favourite_removed": "Poistettu suosikeista",
+ "tab_switch_failed": "Välilehteen vaihto epäonnistui",
+ "tab_content_mismatch": "Tämä välilehti oli epäsynkassa ja ladataan uudelleen",
+ "tab_restore_failed": "Välilehden sivua ei voitu palauttaa",
+ "open_node_failed": "NomadNet-solmua ei voitu avata"
},
"forwarder": {
"title": "LXMF-välittäjä",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 98fd1b90..632d44b4 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1751,7 +1751,11 @@
"context_menu_action_failed": "Échec de l'action du menu contextuel",
"context_menu_favourite_failed": "Impossible de mettre à jour le favori",
"favourite_added": "Ajouté aux favoris",
- "favourite_removed": "Retiré des favoris"
+ "favourite_removed": "Retiré des favoris",
+ "tab_switch_failed": "Impossible de passer à cet onglet",
+ "tab_content_mismatch": "Cet onglet était désynchronisé et est en cours de rechargement",
+ "tab_restore_failed": "Impossible de restaurer la page de cet onglet",
+ "open_node_failed": "Impossible d'ouvrir le nœud NomadNet"
},
"forwarder": {
"title": "Expéditeur LXMF",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 1c3a2c55..8ef0d6e0 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1803,7 +1803,11 @@
"context_menu_action_failed": "Azione del menu contestuale non riuscita",
"context_menu_favourite_failed": "Impossibile aggiornare il preferito",
"favourite_added": "Aggiunto ai preferiti",
- "favourite_removed": "Rimosso dai preferiti"
+ "favourite_removed": "Rimosso dai preferiti",
+ "tab_switch_failed": "Impossibile passare a quella scheda",
+ "tab_content_mismatch": "Questa scheda non era sincronizzata e viene ricaricata",
+ "tab_restore_failed": "Impossibile ripristinare la pagina di questa scheda",
+ "open_node_failed": "Impossibile aprire il nodo NomadNet"
},
"forwarder": {
"title": "Inoltro LXMF",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 198cec4c..0086c72d 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1751,7 +1751,11 @@
"context_menu_action_failed": "Contextmenu-actie mislukt",
"context_menu_favourite_failed": "Favoriet kon niet worden bijgewerkt",
"favourite_added": "Toegevoegd aan favorieten",
- "favourite_removed": "Verwijderd uit favorieten"
+ "favourite_removed": "Verwijderd uit favorieten",
+ "tab_switch_failed": "Kon niet naar dat tabblad wisselen",
+ "tab_content_mismatch": "Dit tabblad was niet gesynchroniseerd en wordt opnieuw geladen",
+ "tab_restore_failed": "Kon de pagina van dit tabblad niet herstellen",
+ "open_node_failed": "Kon het NomadNet-knooppunt niet openen"
},
"forwarder": {
"title": "LXMF Forwarder",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 621456b5..52f0f51b 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1639,7 +1639,11 @@
"context_menu_action_failed": "Не удалось выполнить действие контекстного меню",
"context_menu_favourite_failed": "Не удалось обновить избранное",
"favourite_added": "Добавлено в избранное",
- "favourite_removed": "Удалено из избранного"
+ "favourite_removed": "Удалено из избранного",
+ "tab_switch_failed": "Не удалось переключиться на эту вкладку",
+ "tab_content_mismatch": "Вкладка была не синхронизирована и перезагружается",
+ "tab_restore_failed": "Не удалось восстановить страницу вкладки",
+ "open_node_failed": "Не удалось открыть узел NomadNet"
},
"forwarder": {
"title": "LXMF Форвардер",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 83ee1a1c..bbf8728d 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1751,7 +1751,11 @@
"context_menu_action_failed": "上下文菜单操作失败",
"context_menu_favourite_failed": "无法更新收藏",
"favourite_added": "已添加到收藏",
- "favourite_removed": "已从收藏中移除"
+ "favourite_removed": "已从收藏中移除",
+ "tab_switch_failed": "无法切换到该标签页",
+ "tab_content_mismatch": "此标签页不同步,正在重新加载",
+ "tab_restore_failed": "无法恢复此标签页的页面",
+ "open_node_failed": "无法打开 NomadNet 节点"
},
"forwarder": {
"title": "LXMF 转发器",

diff --git a/tests/frontend/NomadNetworkBrowser.test.js b/tests/frontend/NomadNetworkBrowser.test.js
index 67cdb658..f43290aa 100644
--- a/tests/frontend/NomadNetworkBrowser.test.js
+++ b/tests/frontend/NomadNetworkBrowser.test.js
@@ -4,14 +4,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/components/nomadnetwork/NomadNetworkPage.vue", () => ({
default: {
name: "NomadNetworkPage",
- template: '<div class="nnp-stub" :data-hash="destinationHash"></div>',
+ template: '<div class="nnp-stub" :data-hash="destinationHash" :data-active="isActive ? \'1\' : \'0\'"></div>',
props: {
destinationHash: { type: String, default: "" },
initialPath: { type: String, default: null },
embedded: { type: Boolean, default: false },
tabsEnabled: { type: Boolean, default: false },
+ isActive: { type: Boolean, default: true },
},
emits: ["navigate", "open-node", "close-tab"],
+ methods: {
+ getEmbeddedTabStateHash() {
+ return this._stateHash || "";
+ },
+ restoreEmbeddedTabState(hash) {
+ this._stateHash = hash;
+ },
+ },
},
}));
@@ -35,16 +44,18 @@ const MaterialDesignIconStub = {
describe("NomadNetworkBrowser.vue", () => {
let routerReplace;
+ let routerPush;
const mountBrowser = (props = {}, route = { name: "nomadnetwork", params: {}, query: {} }) => {
routerReplace = vi.fn(() => Promise.resolve());
+ routerPush = vi.fn(() => Promise.resolve());
return mount(NomadNetworkBrowser, {
props,
global: {
mocks: {
$t: (key) => key,
$route: { name: "nomadnetwork", ...route },
- $router: { replace: routerReplace },
+ $router: { replace: routerReplace, push: routerPush },
},
stubs: {
MaterialDesignIcon: MaterialDesignIconStub,
@@ -56,6 +67,7 @@ describe("NomadNetworkBrowser.vue", () => {
beforeEach(() => {
localStorage.clear();
routerReplace = undefined;
+ routerPush = undefined;
vi.clearAllMocks();
});
@@ -331,6 +343,99 @@ describe("NomadNetworkBrowser.vue", () => {
expect(wrapper.findComponent({ name: "NomadNetworkPage" }).props("tabsEnabled")).toBe(true);
});
+ it("marks only the active embedded page as isActive", async () => {
+ const wrapper = mountBrowser();
+ const hashA = "a".repeat(32);
+ const hashB = "b".repeat(32);
+ wrapper.vm.onTabNavigate(wrapper.vm.tabs[0].id, {
+ destinationHash: hashA,
+ pagePath: "/page/index.mu",
+ title: "Tab A",
+ });
+ const tabB = wrapper.vm.addTab(hashB, "/page/index.mu", "Tab B");
+ await wrapper.vm.$nextTick();
+
+ const pages = wrapper.findAllComponents({ name: "NomadNetworkPage" });
+ expect(pages).toHaveLength(2);
+ const activePage = pages.find((page) => page.props("isActive") === true);
+ const inactivePage = pages.find((page) => page.props("isActive") === false);
+ expect(activePage?.props("destinationHash")).toBe(hashB);
+ expect(inactivePage?.props("destinationHash")).toBe(hashA);
+
+ wrapper.vm.selectTab(wrapper.vm.tabs[0].id);
+ await wrapper.vm.$nextTick();
+
+ const pagesAfter = wrapper.findAllComponents({ name: "NomadNetworkPage" });
+ expect(pagesAfter[0].props("isActive")).toBe(true);
+ expect(pagesAfter[1].props("isActive")).toBe(false);
+ expect(pagesAfter[0].props("destinationHash")).toBe(hashA);
+ });
+
+ it("keeps first tab metadata when opening a visualizer node in a new tab", async () => {
+ const wrapper = mountBrowser();
+ const hashA = "c".repeat(32);
+ const hashB = "d".repeat(32);
+ const firstTabId = wrapper.vm.tabs[0].id;
+ wrapper.vm.onTabNavigate(firstTabId, {
+ destinationHash: hashA,
+ pagePath: "/page/index.mu",
+ title: "First",
+ });
+ wrapper.vm.onOpenNode({
+ destinationHash: hashB,
+ pagePath: "/page/other.mu",
+ title: "Visualizer Node",
+ forceNewTab: true,
+ });
+ wrapper.vm.selectTab(firstTabId);
+ await wrapper.vm.$nextTick();
+
+ const firstTab = wrapper.vm.tabs.find((tab) => tab.id === firstTabId);
+ expect(firstTab.destinationHash).toBe(hashA);
+ expect(wrapper.vm.activeTabId).toBe(firstTabId);
+ });
+
+ it("handleNomadOpenNode from another route opens a new tab after navigation", async () => {
+ const dest = "e".repeat(32);
+ const wrapper = mountBrowser({}, { name: "network-visualiser", params: {}, query: {} });
+ wrapper.vm.handleNomadOpenNode({ destinationHash: dest, forceNewTab: true });
+ expect(routerPush).toHaveBeenCalledWith({
+ name: "nomadnetwork",
+ params: { destinationHash: dest },
+ query: { newTab: "1" },
+ });
+ await routerPush.mock.results[0].value;
+ wrapper.vm.$route = {
+ name: "nomadnetwork",
+ params: { destinationHash: dest },
+ query: { newTab: "1" },
+ };
+ wrapper.vm.consumeNewTabRouteQuery(wrapper.vm.$route);
+ expect(wrapper.vm.tabs).toHaveLength(2);
+ expect(wrapper.vm.activeTab.destinationHash).toBe(dest);
+ });
+
+ it("verifyActiveTabPage restores mismatched embedded page state", async () => {
+ const wrapper = mountBrowser();
+ const hashA = "f".repeat(32);
+ const hashB = "0".repeat(32);
+ const tabId = wrapper.vm.tabs[0].id;
+ wrapper.vm.onTabNavigate(tabId, { destinationHash: hashA, pagePath: "/page/index.mu" });
+ wrapper.vm.pageRefs[tabId] = {
+ getEmbeddedTabStateHash: () => hashB,
+ restoreEmbeddedTabState: vi.fn(),
+ };
+ wrapper.vm.verifyActiveTabPage(wrapper.vm.tabs[0]);
+ expect(wrapper.vm.pageRefs[tabId].restoreEmbeddedTabState).toHaveBeenCalledWith(hashA, "/page/index.mu");
+ expect(ToastUtils.warning).toHaveBeenCalledWith("nomadnet.tab_content_mismatch");
+ });
+
+ it("selectTab warns when tab id is unknown", () => {
+ const wrapper = mountBrowser();
+ wrapper.vm.selectTab(99999);
+ expect(ToastUtils.warning).toHaveBeenCalledWith("nomadnet.tab_switch_failed");
+ });
+
it("reorders tabs while dragging", () => {
const wrapper = mountBrowser();
wrapper.vm.addTab("a".repeat(32), null, "Tab A");

diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index a3785d05..e135dec0 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -22,11 +22,22 @@ vi.mock("@/js/ToastUtils", () => ({
},
}));
+const wsMessageHandlers = [];
+
vi.mock("@/js/WebSocketConnection", () => ({
default: {
send: vi.fn(),
- on: vi.fn(),
- off: vi.fn(),
+ on: vi.fn((event, handler) => {
+ if (event === "message") {
+ wsMessageHandlers.push(handler);
+ }
+ }),
+ off: vi.fn((event, handler) => {
+ const index = wsMessageHandlers.indexOf(handler);
+ if (index >= 0) {
+ wsMessageHandlers.splice(index, 1);
+ }
+ }),
},
}));
@@ -40,6 +51,7 @@ describe("NomadNetworkPage.vue", () => {
delete: vi.fn(),
};
window.api = axiosMock;
+ wsMessageHandlers.length = 0;
vi.clearAllMocks();
axiosMock.get.mockImplementation((url) => {
@@ -608,4 +620,92 @@ describe("NomadNetworkPage.vue", () => {
expect(wrapper.vm.standaloneContextMenu.show).toBe(false);
});
});
+
+ describe("embedded tab websocket isolation", () => {
+ const hashA = "a".repeat(32);
+ const hashB = "b".repeat(32);
+ const pagePath = "/page/index.mu";
+
+ const emitPageDownload = (destinationHash, status, extra = {}) => ({
+ data: JSON.stringify({
+ type: "nomadnet.page.download",
+ download_id: 42,
+ nomadnet_page_download: {
+ destination_hash: destinationHash,
+ page_path: pagePath,
+ status,
+ page_content: "<p>other tab</p>",
+ ...extra,
+ },
+ }),
+ });
+
+ it("inactive embedded instance ignores another tab's archived page download", async () => {
+ const active = mountNomadNetworkPage({
+ destinationHash: "",
+ embedded: true,
+ isActive: true,
+ });
+ const inactive = mountNomadNetworkPage({
+ destinationHash: "",
+ embedded: true,
+ isActive: false,
+ });
+ await active.vm.$nextTick();
+ await inactive.vm.$nextTick();
+
+ active.vm.selectedNode = { destination_hash: hashA, display_name: "A" };
+ active.vm.nodePagePath = `${hashA}:${pagePath}`;
+ active.vm.nodePageContent = "<p>tab a</p>";
+
+ inactive.vm.selectedNode = { destination_hash: hashA, display_name: "A" };
+ inactive.vm.nodePagePath = `${hashA}:${pagePath}`;
+ inactive.vm.nodePageContent = "<p>tab a copy</p>";
+
+ await inactive.vm.onWebsocketMessage(emitPageDownload(hashB, "success", { is_archived_version: true }));
+
+ expect(active.vm.nodePageContent).toBe("<p>tab a</p>");
+ expect(inactive.vm.nodePageContent).toBe("<p>tab a copy</p>");
+ active.unmount();
+ inactive.unmount();
+ });
+
+ it("inactive embedded instance ignores started events without a callback", async () => {
+ const inactive = mountNomadNetworkPage({
+ destinationHash: "",
+ embedded: true,
+ isActive: false,
+ });
+ await inactive.vm.$nextTick();
+ inactive.vm.selectedNode = { destination_hash: hashA, display_name: "A" };
+ inactive.vm.nodePagePath = `${hashA}:${pagePath}`;
+ inactive.vm.nodePageContent = "<p>stable</p>";
+
+ await inactive.vm.onWebsocketMessage(emitPageDownload(hashB, "started"));
+
+ expect(inactive.vm.currentPageDownloadId).toBeNull();
+ expect(inactive.vm.nodePageLoadPhase).toBeNull();
+ expect(inactive.vm.nodePageContent).toBe("<p>stable</p>");
+ inactive.unmount();
+ });
+
+ it("active instance with callback still receives page download success", async () => {
+ const wrapper = mountNomadNetworkPage({
+ destinationHash: "",
+ embedded: true,
+ isActive: true,
+ });
+ await wrapper.vm.$nextTick();
+
+ const onSuccess = vi.fn();
+ wrapper.vm.nomadnetPageDownloadCallbacks[`${hashB}:${pagePath}`] = {
+ onSuccessCallback: onSuccess,
+ };
+
+ await wrapper.vm.onWebsocketMessage(emitPageDownload(hashB, "success"));
+
+ expect(onSuccess).toHaveBeenCalledWith("<p>other tab</p>");
+ wrapper.unmount();
+ });
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────